終於來到鐵人賽的後半段囉~給自己一點鼓勵!
前半段都在理解和複習Vue的一些使用上的觀念和陷阱,接下來希望能複習一些基本JavaScript觀念,讓我們可以慢慢推進到更進階的JavaScript設計模式等。
之前已經有處理一些重複性邏輯時,會使用所謂的組合式函式(composable)來達成複用,其中衍生的觀念式是從JavaScript工廠模式(Factory Pattern),是一個很基礎但常出現的設計模式,只是不太常察覺。
順便熟悉一下另一個類別建構子(class),兩者同樣能達成共用物件或擴充的目的。今天就稍微複習一下,兩者沒有絕對好或壞,針對專案需求可以選擇覺得合適的設計方案就行。~
類別建構子和工廠函式的差別閉包(closure)觀念再複習私有變數(private)
延展和繼承(extend and inheritance),差異性在哪裡?類別建構子和工廠函式的差別類別建構子(class) 是 ECMAScript 2015 (ES6) 規範中的 JavaScript 新增的特性。類別提供了一種就像是物件導向程式設計(OOP)方法,對於使用 Java 或 C++ 等語言的開發人員來說,會感到很熟悉,雖然本質上JavaSript 是沒有class系統,是用原型繼承(prototype inheritance)來模擬。
class關鍵字來定義constructor用於初始化新實例的內部變數原型(prototype)中this來指向建構子的變數(非同步稍微注意指向問題)extend關鍵字來實現繼承class Rectangle {
  // 建構函式定義初始化物件綁定資料
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
  // 內部定義方法
  getArea() {
    return this.height * this.width;
  }
}
const rect = new Rectangle(10, 20);
console.log(rect.getArea()); // 200
工廠函式(factory function)是一個在ES6之前還沒有類別出現時常見的設計模式,是將物件的創建過程封裝在函式中,並透過該函式來生成新的物件,當然也可以只返回一個特定變數,相較於類別在回傳值上有更高靈活性。
我們可以用工廠函式於創建對象的過程中不直接使用new 關鍵字來實例化對象,可以通過函數來生成和返回對象。
不使用new關鍵字創建,採用熟悉的函式呼叫(invoke)function createRectangle(height, width) {
  return {
    height,
    width, 
    getArea() {
      return this.height * this.width;
    }
  }
}
const rect = createRectangle(10, 20);
console.log(rect.getArea()); // 200
先看一個簡單的應用,為什麼呼叫 makeAdding 函式並輸入參數5,最終可以得到返回值7呢?
function makeAdding (firstNumber) {
  const first = firstNumber;
  return function resulting (secondNumber) {
    const second = secondNumber;
    return first + second;
  }
}
const add5 = makeAdding(5);
console.log(add5(2)) // logs 7 
函數makeAdding接受一個參數 ,firstNumber宣告一個first值為 的常數firstNumber,並傳回另一個函數。
當參數2傳遞給傳回的函式add5時,它會傳回將先前傳遞的數字5與現在傳遞的數字2相加的結果,也就是7,在JavaScript中函式會形成閉包(closure)。
閉包是指函式和其被宣告時所在的周圍狀態(也稱為語彙環境lexiccal scope)的組合,這個周圍狀態包含了函式建立時在作用域內的所有局部變數。
當 makeAdding 函式執行(execution phase)時,add5 是指向makeAdding函式,其中包含了變數 first,JS為了能夠正確運行並捕捉對應的變數資料來進行運算,透過宣告時期(creation phase)已經建立好的語彙環境,去搜尋變數 first被賦予的值,也就是5。
工廠函式的一大優點是能夠利用閉包(closure)來創建「私有」變數和函式,這些變數和函式只能在工廠函式內部訪問,而無法從外部直接存取。
可以很直觀透過返回值去控制模組需要公開的方法和屬性,這使得模組的使用者只能使用經過我們設計過的API介面(interface),增強了程式碼的可靠性和易用性。
function createUser(name) {
  // 私有變數
  let privateId = Math.random().toString(36).substr(2, 9);
  // 私有函式
  function generateGreeting() {
    return `Hello, ${name}! Your ID is ${privateId}.`;
  }
  // 公開的變數和函式
  const discordName = "@" + name;
  return {
    name,
    discordName,
    getGreeting: generateGreeting, // 公開方法訪問私有函式
    getId: () => privateId, // 公開方法訪問私有變數
  };
}
// 使用工廠函式
const user = createUser("Alice");
// 無法直接訪問私有變數和函式
console.log(user.privateId); // undefined
console.log(user.generateGreeting); // undefined
當然你會想說物件建構子做不到實現私有屬性和方法嗎,其實好像可以, ES2022 引入了私有屬性語法(使用 # 前綴),讓我們可以在 class 中定義真正的私有屬性和方法:
class User {
  // 私有變數使用 # 前綴
  #privateId;
  #privateMethod() {
    console.log("This is a private method.");
  }
  constructor(name) {
    this.name = name;
    this.discordName = "@" + name;
    this.#privateId = Math.random().toString(36).substr(2, 9);
  }
  // 公開方法可以訪問私有屬性和方法
  getId() {
    return this.#privateId;
  }
  publicMethod() {
    console.log("This is a public method.");
    this.#privateMethod(); // 呼叫私有方法
  }
}
const user = new User("Alice");
// 嘗試訪問私有屬性或方法會失敗
console.log(user.#privateId); // SyntaxError: Private field '#privateId' must be declared in an enclosing class
console.log(user.#privateMethod); // SyntaxError: Private field '#privateMethod' must b
類別建構子使用extend滿方便的,在新的物件實例使用起來也滿直觀~
但目前看到比較嚴重的缺點是方法屬性同名的話,後代會有改寫並覆蓋的情況,在實務上應該比較不希望這種狀況產生,如果後面的高階模組一直蓋掉低階模組,使用起來好像會混淆。
需要做一層屬性定義defineProperty,不然會後代新方法覆蓋掉原本父設計好函式的風險。
class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}
// 凍結 speak 方法,不可以覆寫
Object.defineProperty(Animal.prototype, 'speak', {
  writable: false,
  configurable: false
});
class Dog extends Animal {
  constructor(name) {
    super(name);
  }
  speak() {
    console.log(`${this.name} barks.`);
  }
}
const dog = new Dog(‘Rex‘);
dog.speak(); // Rex barks.
工廠函式上面有介紹過會返回一個新的物件,而不需要使用 new 關鍵字。
如果希望做到可以有類似class父子類繼承作用的,需要使用 Object.assign 或展開運算子(spread operator)將原本的函式中的方法複製到新物件,我記得之前物件操作方法章節有提到。
不過個人感覺使用上不像類別extend,有較清楚的物件繼承上下關係,反而比較像混合模式(Mixin pattern),將各種屬性集中到同一物件,因為同樣會有作用域命名(named scope)上的問題,合併順序就滿重要,因為後面同樣的屬性會覆蓋掉前面重複名稱。
。
// 製作一個基本動物函式
const animalMethods = {
  speak() {
    console.log(`${this.name} makes a noise.`);
  }
};
const runnerMethods = {
  run() {
    console.log(`${this.name} is running.`);
  }
};
// 工廠函式繼承多個方法
function createAnimal(name) {
  return {
    name,
    ...animalMethods,
    ...runnerMethods // 使用展開運算子將多個方法合併
  };
}
// 創建一隻動物
const myAnimal = createAnimal('Cheetah');
myAnimal.speak(); // Cheetah makes a noise.
myAnimal.run();   // Cheetah is running.
const pobby = createAnimal(Cheetah);
// 檢查 pobby 的原形
console.log(Object.getPrototypeOf(pobby)); // 輸出: Object.prototype
console.log(pobby instanceof createAnimal); // 輸出: false
工廠函式返回的物件沒有利用 JavaScript 的原型鏈來共享方法,每個物件都是獨立的,方法是直接附加在物件上的。這些方法並不共享,而是每個物件有自己的一份副本。這和 class 不同,class 可以利用原型來共享方法。
在類別class中,當你定義方法時,這些方法會被添加到類別的原型鏈上共享。也就是說,所有由這 class 創建的物件實例都會共享同一個方法,而不是每個物件都有自己的副本,不過在目前設備硬體都很充分情況下,大部分開發效能差異性並不大。
使用類別class的方式更符合熟悉物件導向概念的開發者,尤其當設計關係上出現,需要多層次繼承時會更具可讀性和可維護性。
| 特性 | 工廠函式 | 類別class | 
|---|---|---|
| 方法位置 | 每個實例都有自己的方法副本 | 方法位於原型鏈上,所有實例共享同一個方法 | 
| 記憶體效率 | 每次創建都複製方法,可能會浪費記憶體(很大量的話) | 方法共享,記憶體使用更有效 | 
| 代碼風格與結構 | 更具靈活性,可自己定義返回的物件 | 適合更複雜的物件結構,符合 OOP 模式 | 
| 擴展性 | 靈活,但擴展較複雜,比較沒上下關係,偏混合式(mixin)風格 | 直接使用 extends 繼承,明確層級關係,可讀性會比較高 |